Skip to content

feat(rig): provision tg-ctl inbound daemon as a boot LaunchAgent#30

Merged
alex-mextner merged 5 commits into
mainfrom
rig-tg-ctl-boot
Jun 17, 2026
Merged

feat(rig): provision tg-ctl inbound daemon as a boot LaunchAgent#30
alex-mextner merged 5 commits into
mainfrom
rig-tg-ctl-boot

Conversation

@alex-mextner

Copy link
Copy Markdown
Owner

What

rig init / rig apply now provision the tg-ctl inbound control daemon (tg-cli's
long-poll / inject-into-tmux / voice→text daemon, run as tg-ctl run) as a macOS boot
LaunchAgent
so it auto-starts at login/boot — exactly like rig already does for the tmux
boot service. On a clean machine, rig init alone sets it up (the block is default-ON).

Design — mirrors tmux-boot

The whole pipeline follows the existing tmux pattern (one source of truth shared by plan,
apply, drift):

  • riglib/tg_ctl.py — a pure, stdlib-only, effect-free TgCtlPlan (analog of
    riglib.tmux) that renders ~/Library/LaunchAgents/ai.hyperide.tg-ctl.plist. It uses
    plistlib.dumps(..., sort_keys=False) so the key order matches the hand-created, working
    live plist byte-for-byterig apply against the live file is a true no-op (skipped),
    never a spurious rewrite. Paths are derived from $HOME; bun is discovered (which bun
    ~/.bun/bin/bun fallback); config dir honors $TG_CTL_CONFIG_DIR (default ~/.config/tg-cli).
  • runner._do_provision_tg_ctl — render → back up a differing prior (timestamped) → write →
    (re)load. Unlike tmux-boot (which only writes the plist), this agent is (re)loaded via
    launchctl bootout/bootstrap in the per-user gui/<uid> domain, so a clean rig init
    starts the daemon without a reboot. Reuses _timestamped_backup_path and the fsutil
    conflict engine.
  • drift._check_tg_ctl — flags missing / divergent / written-but-not-loaded, a leftover
    plist when boot:false, and the stale predecessor (extra). rig status surfaces it in the
    GLOBAL section (installed / drifted / disabled / unsupported-off-darwin).

Config block (GLOBAL layer)

Per-machine concern → belongs in ~/.config/rig/config.yaml (like harness/tmux/git_hooks),
NOT a committed repo rig.yaml. Default ON; enabled/boot default true. Follows the tmux:
schema style (fail-closed validation). See docs/config-schema.md#tg_ctl.

Stale-predecessor removal

If ~/Library/LaunchAgents/com.ultra.codex-tg-bot.plist (the dead predecessor) exists,
rig apply boots it out, backs it up (timestamped), and removes it.

Idempotency — proven against the live plist

A real rig apply (no dry-run) against this machine's live, loaded ai.hyperide.tg-ctl.plist
is a skipped no-op: the plist sha is unchanged and no bootstrap/bootout fires (the
byte-identical-AND-loaded early-return). Verified directly.

Tests / smoke

  • pytest: 510 passed (uv run --extra test pytest tests/).
  • smoke: exit 0 (bash tests/smoke.sh), including a new focused, HOME-isolated,
    RIG_TG_CTL_DRY_RUN tg-ctl leg.
  • Hard isolation: no test/smoke ever touches the real ~/Library/LaunchAgents or runs real
    launchctl — conftest neutralizes the default-on provisioner + drift check and stubs the
    gui-domain launchctl seams suite-wide; dedicated tests restore the real ones with their own
    temp HOME + a RIG_TG_CTL_DRY_RUN-style dry-run seam. The real tg-ctl plist sha was unchanged
    by the entire test run.

Review

Ran multi-model review on the diff; addressed its findings — fixed a dry-run disk-mutation
leak in the stale teardown, boot:null/label:null defaulting, off-darwin status wording,
collapsed the launchctl bootout/bootstrap helpers, and added regression tests for each.

Do NOT merge — draft.

🤖 Generated with Claude Code

alex-mextner and others added 5 commits June 17, 2026 07:16
Add the tg_ctl config block (validate + plan) and the pure, effect-free
TgCtlPlan that renders the ai.hyperide.tg-ctl.plist LaunchAgent XML
byte-exact to the working hand-created file (sort_keys=False preserves the
insertion order so a re-apply is a true no-op). Default-on, per-machine
(GLOBAL layer), macOS-only. Mirrors the tmux block's schema style.

boot:null and label:null resolve to their defaults (not bool(None)=False /
str(None)="None").

Reviewed via multi-model `review`; findings addressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runner: _do_provision_tg_ctl writes the byte-exact plist, backs up a
differing prior, ensures the log dir, tears down the stale predecessor
(com.ultra.codex-tg-bot: bootout + timestamped backup + remove), and
(re)loads via launchctl bootout/bootstrap in the gui/<uid> domain. A
re-apply against the already-correct loaded plist is a skipped no-op.
RIG_TG_CTL_DRY_RUN writes the plist but skips every live/destructive
mutation (launchctl AND the stale teardown) so tests/smoke never touch the
real launchd domain.

Drift: _check_tg_ctl flags missing / divergent / written-but-not-loaded, a
leftover plist when boot:false, and the stale predecessor (extra). CLI:
GLOBAL status line shows installed / drifted / disabled / unsupported
(off-darwin), resolved through the shared plan builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_tg_ctl.py mirrors test_tmux.py: config validation, byte-exact plist
render (incl. against the live machine plist when present, read-only),
create/idempotent/conflict/dry-run states, stale-predecessor teardown, drift
(missing/modified/extra/not-loaded), status states, and the boot:null /
label:null / dry-run-no-stale-removal / off-darwin regressions.

conftest neutralizes the default-on tg_ctl provisioner + drift check and
stubs the gui-domain launchctl seams suite-wide (dedicated tests restore the
real ones with their own HOME-isolated tmp dirs); no test ever touches the
real ~/Library/LaunchAgents or runs real launchctl. smoke.sh gains a
focused, HOME-isolated, RIG_TG_CTL_DRY_RUN tg-ctl leg and prefers
`uv run --extra test pytest`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs/config-schema.md: the tg_ctl section (keys, defaults, the byte-exact
no-op contract, gui-domain (re)load, stale-predecessor teardown, drift, the
RIG_TG_CTL_DRY_RUN seam, and the enabled:false vs boot:false distinction) +
the validation paragraph. AGENTS.md: refine the "never mutate a LIVE service"
rule — the stateless background daemons (models cron, tg_ctl) are the
documented (re)load exceptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ixture

#27's clean-sample leg enumerates every default-ON category and disables
it to assert zero false drift. tg_ctl (added by this PR) is default-ON, so
its provision_tg_ctl action made the clean sample report drift (exit 3).
Mirror how #29 added 'gitignore: {enabled: false}' and disable tg_ctl too.
@alex-mextner alex-mextner marked this pull request as ready for review June 17, 2026 05:24
@alex-mextner alex-mextner merged commit cb076d3 into main Jun 17, 2026
14 checks passed
@alex-mextner alex-mextner deleted the rig-tg-ctl-boot branch June 17, 2026 05:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9eb0531761

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread riglib/drift.py
"tg-ctl boot LaunchAgent differs from the configured plist")
)
return
if not _launchctl_gui_loaded(plan.boot_label):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor tg_ctl dry-run when checking launchd

When RIG_TG_CTL_DRY_RUN=1 is set, _do_provision_tg_ctl intentionally writes the plist but skips launchctl bootstrap; this status check still queries the real launchd domain and reports the freshly written agent as “installed but not loaded.” On macOS, a dry-run-isolated rig init/apply (including smoke/CI runs that set this env var to avoid touching launchd) can therefore never be followed by a clean rig status unless it performs the live mutation the flag is meant to prevent. Gate this loaded-state check under the same dry-run condition as the provisioner.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant